<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>LangFlow API Tester</title>
<style>
  body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', 'PingFang SC', 'Microsoft YaHei', sans-serif; margin: 0; padding: 0; background: #f6f7f9; }
  .container { max-width: 1380px; margin: 24px auto; background: #fff; border: 1px solid #e5e7eb; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.04); }
  header { padding: 16px 20px; border-bottom: 1px solid #eef2f7; position: relative; }
  header h1 { margin: 0; font-size: 20px; }
  .nav-link { position: absolute; top: 16px; right: 20px; background: #2563eb; color: #fff; padding: 8px 16px; border-radius: 6px; text-decoration: none; font-weight: 600; box-shadow: 0 2px 8px rgba(37,99,235,0.3); }
  .content { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; padding: 16px 20px 24px; }
  .card { border: 1px solid #eef2f7; border-radius: 8px; padding: 12px; background: #fafbfc; }
  label { display:block; font-size: 13px; color:#334155; margin: 8px 0 6px; }
  input[type=text], textarea, select { width: 100%; box-sizing: border-box; padding: 10px 12px; border: 1px solid #cbd5e1; border-radius: 6px; outline: none; font-size: 13px; background: #fff; }
  textarea { min-height: 120px; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; }
  .row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
  .row-3 { display: grid; grid-template-columns: 1fr 1fr auto; gap: 8px; align-items: end; }
  .actions { display:flex; gap: 12px; margin-top: 12px; }
  button { cursor: pointer; padding: 6px 10px; border-radius: 6px; border: 1px solid #2563eb; background: #2563eb; color: #fff; font-weight: 600; white-space: nowrap; }
  button.secondary { background: #fff; color: #1f2937; border-color: #cbd5e1; }
  button.danger { background: #b91c1c; border-color: #b91c1c; }
  .muted { color:#64748b; font-size: 12px; }
  pre { white-space: pre-wrap; word-break: break-word; background: #0b1021; color: #d1e7ff; padding: 12px; border-radius: 6px; font-size: 12px; line-height: 1.5; }
  .status { font-size: 13px; color:#0f172a; }
  .tweak-item { border: 1px dashed #cbd5e1; border-radius: 8px; padding: 10px; background: #fff; margin-top: 10px; }
  .container-card { border: 1px solid #e5e7eb; border-radius: 10px; padding: 12px; margin-top: 12px; background: #ffffff; position: relative; }
  .container-header { display:flex; align-items: end; justify-content: space-between; gap: 12px; padding-bottom: 8px; border-bottom: 1px dashed #e2e8f0; }
  .container-title { display:flex; align-items:center; gap:8px; }
  .badge { background:#eef2ff; color:#3730a3; font-weight:700; padding:2px 6px; border-radius:999px; font-size:11px; border:1px solid #c7d2fe; }
  .ct-name { flex-grow: 1; min-width: 100px; }
  .container-actions { display:flex; gap:6px; }
  .keys-wrap { margin-top: 10px; padding-left: 14px; border-left: 3px solid #e5e7eb; }
  .key-card .tw-value { background:#ffffff; }
  .markdown-output { position: relative; background: #f8f9fa; padding: 12px; border-radius: 6px; font-size: 13px; line-height: 1.6; border: 1px solid #e9ecef; max-height: 400px; }
  .markdown-content-wrapper { max-height: 350px; overflow-y: auto; white-space: pre-wrap; word-break: break-word; margin: 0; }
  .markdown-content { margin: 0; padding: 0; line-height: 1.6; }
  .markdown-toggle { background-color: #f1f5f9; border: 1px solid #e2e8f0; color: #475569; padding: 4px 12px; border-radius: 4px; font-size: 12px; cursor: pointer; margin-top: 8px; position: static; }
  @media (max-width: 768px) { .markdown-output { padding: 12px; font-size: 14px; } .markdown-toggle { font-size: 11px; padding: 3px 10px; } }
  @media (max-width: 480px) { .markdown-output { padding: 10px; font-size: 13px; } .markdown-toggle { font-size: 10px; padding: 2px 8px; } }
  .markdown-collapsed .markdown-content-wrapper { max-height: 100px; overflow: hidden; position: relative; }
  .markdown-collapsed .markdown-content-wrapper:after { content: ""; position: absolute; bottom: 0; left: 0; right: 0; height: 20px; background: linear-gradient(transparent, #f8f9fa); }
  .markdown-collapsed .markdown-content { max-height: 100px; overflow: hidden; position: relative; }
  .markdown-collapsed .markdown-content:after { content: ""; position: absolute; bottom: 0; left: 0; right: 0; height: 20px; background: linear-gradient(transparent, #f8f9fa); }
</style>
</head>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<body>
  <div class="container">
    <header>
      <h1>LangFlow API 测试工具</h1>
      <a href="/batch" class="nav-link">批量测试</a>
      <div class="muted">输入参数后点击“发送请求”。返回：调用耗时与流程输出内容。</div>
    </header>
    <div class="content">
      <div class="card">
        <label>URL</label>
        <input id="url" type="text" value="{{ default_url }}" />
        <div class="row" style="margin-top:8px;">
          <div>
            <label>session_id</label>
            <input id="session_id" type="text" value="{{ default_session_id }}" />
          </div>
          <div>
            <label>Headers.x-api-key（可选）</label>
            <input id="api_key" type="text" placeholder="如果网关需要，填写 x-api-key" />
          </div>
        </div>
        <label style="margin-top:8px;">input_value（多行文本）</label>
        <textarea id="input_value" placeholder="在此粘贴病历原文或任意输入"></textarea>
        <div style="margin-top:10px;">
          <label>
            <input type="checkbox" id="stream_output" /> 启用流式输出
          </label>
        </div>
        <div class="actions">
          <button id="add_container" class="secondary">+ 添加组件名</button>
          <button id="clear_containers" class="danger">清空所有组件名</button>
        </div>
        <div id="containers"></div>
        <div class="actions" style="margin-top:14px;">
          <button id="send">发送请求</button>
          <button class="secondary" id="reset">重置</button>
          <button class="secondary" id="copy_curl">复制为CURL</button>
        </div>
        <div class="status" id="status"></div>
      </div>
      <div class="card">
        <label>调用耗时（秒）</label>
        <input id="duration" type="text" readonly />
        <label style="margin-top:8px;">流程输出内容</label>
        <div id="output" class="markdown-output">
          <div class="markdown-content-wrapper">
            <div class="markdown-content"></div>
          </div>
          <div class="markdown-toggle" style="display: none;">展开/收起</div>
        </div>
      </div>
      <div class="card">
        <label>cURL</label>
        <textarea id="curl_input" placeholder="粘贴 cURL 命令"></textarea>
        <div class="actions">
          <button class="secondary" id="parse_curl">从CURL回填</button>
        </div>
      </div>
    </div>
  </div>
<script>
const $ = (id) => document.getElementById(id);
const DEFAULT_URL = "{{ default_url }}";
const DEFAULT_SESSION = "{{ default_session_id }}";
const DEFAULT_CONTAINER_NAME = "{{ default_container_name }}";
const DEFAULT_ENTRY_NAME = "{{ default_entry_name }}";
const DEFAULT_ENTRY_TYPE = "{{ default_entry_type }}";
const DEFAULT_ENTRY_VALUE = {{ default_entry_value | tojson }};
const DEFAULT_ENV_API_KEY = "{{ env_api_key or '' }}";

document.addEventListener('DOMContentLoaded', () => {
  const contentDiv = document.querySelector('.markdown-content');
  if (contentDiv) contentDiv.innerHTML = '';
});

function createKeyItem(name = DEFAULT_ENTRY_NAME, type = DEFAULT_ENTRY_TYPE, value = DEFAULT_ENTRY_VALUE) {
  const wrap = document.createElement('div');
  wrap.className = 'tweak-item key-card';
  wrap.innerHTML = `
    <div class="row-3">
      <div>
        <label>键名</label>
        <input type="text" class="tw-name" value="${name}" />
      </div>
      <div>
        <label>值类型</label>
        <select class="tw-type">
          <option value="NestedDict">NestedDict</option>
          <option value="str">str</option>
          <option value="int">int</option>
          <option value="float">float</option>
          <option value="bool">bool</option>
          <option value="list">list</option>
          <option value="connect">connect</option>
          <option value="custom">自定义</option>
        </select>
        <input type="text" class="tw-type-custom" placeholder="自定义类型名" style="display:none;margin-top:8px;" />
      </div>
      <div style="display:flex;align-items:flex-end;gap:8px;">
        <button type="button" class="secondary tw-fill-demo">填入示例</button>
        <button type="button" class="danger tw-remove">删除键名</button>
      </div>
    </div>
    <label style="margin-top:8px;">值</label>
    <textarea class="tw-value">${value}</textarea>
  `;
  const typeSel = wrap.querySelector('.tw-type');
  const typeCus = wrap.querySelector('.tw-type-custom');
  typeSel.value = type;
  typeCus.style.display = (type === 'custom') ? 'block' : 'none';
  typeSel.addEventListener('change', () => {
    typeCus.style.display = (typeSel.value === 'custom') ? 'block' : 'none';
  });
  wrap.querySelector('.tw-remove').onclick = () => wrap.remove();
  wrap.querySelector('.tw-fill-demo').onclick = () => {
    wrap.querySelector('.tw-value').value = DEFAULT_ENTRY_VALUE;
  };
  return wrap;
}

function createContainer(containerName = DEFAULT_CONTAINER_NAME) {
  const box = document.createElement('div');
  box.className = 'container-card';
  box.innerHTML = `
    <div class="tree-connector"></div>
    <div class="container-header">
      <div class="container-title">
        <span class="badge">组件名</span>
        <input type="text" class="ct-name" value="${containerName}" />
      </div>
      <div class="container-actions">
        <button type="button" class="secondary ct-add-key">+ 添加键名</button>
        <button type="button" class="secondary ct-toggle">折叠/展开</button>
        <button type="button" class="danger ct-remove">删除组件名</button>
      </div>
    </div>
    <div class="keys-wrap ct-keys"></div>
  `;
  const keysWrap = box.querySelector('.ct-keys');
  const addKeyBtn = box.querySelector('.ct-add-key');
  const removeBtn = box.querySelector('.ct-remove');
  const toggleBtn = box.querySelector('.ct-toggle');
  const updateHasKeys = () => {
    if (keysWrap.children.length > 0) box.classList.add('has-keys'); else box.classList.remove('has-keys');
  };
  const appendKey = () => {
    const item = createKeyItem();
    keysWrap.appendChild(item);
    const rm = item.querySelector('.tw-remove');
    if (rm) rm.addEventListener('click', () => setTimeout(updateHasKeys, 0));
    updateHasKeys();
  };
  addKeyBtn.onclick = appendKey;
  removeBtn.onclick = () => box.remove();
  toggleBtn.onclick = () => { box.classList.toggle('ct-collapsed'); };
  appendKey();
  return box;
}

function collectTweaks() {
  const ret = {};
  const containers = document.querySelectorAll('#containers > div');
  for (const c of containers) {
    let cname = c.querySelector('.ct-name').value.trim();
    if (!cname) cname = DEFAULT_CONTAINER_NAME;
    const inner = {};
    const items = c.querySelectorAll('.tweak-item');
    for (const item of items) {
      const name = item.querySelector('.tw-name').value.trim();
      const typeSel = item.querySelector('.tw-type').value;
      const typeCus = item.querySelector('.tw-type-custom').value.trim();
      const valueText = item.querySelector('.tw-value').value || '';
      if (!name) continue;
      const t = (typeSel === 'custom') ? (typeCus || 'custom') : typeSel;
      let valueOut;
      if (t === 'NestedDict' || t === 'Dict') {
        try { valueOut = valueText ? JSON.parse(valueText) : {}; } catch (e) { throw new Error(`组件名[${cname}] - 键名[${name}] 的值不是合法 JSON（类型: ${t}）`); }
      } else { valueOut = valueText; }
      inner[name] = { type: t, value: valueOut };
    }
    ret[cname] = inner;
  }
  return ret;
}

function resetTweaksToDefault() {
  const containers = document.getElementById('containers');
  containers.innerHTML = '';
  containers.appendChild(createContainer());
}

$("add_container").onclick = () => { document.getElementById('containers').appendChild(createContainer(DEFAULT_CONTAINER_NAME)); };
$("clear_containers").onclick = () => { document.getElementById('containers').innerHTML = ''; };

$("reset").onclick = () => {
  $("url").value = DEFAULT_URL;
  $("session_id").value = DEFAULT_SESSION;
  $("api_key").value = "";
  $("input_value").value = "";
  resetTweaksToDefault();
  $("duration").value = "";
  const outputDiv = $("output");
  const contentDiv = outputDiv.querySelector(".markdown-content");
  const toggleBtn = outputDiv.querySelector(".markdown-toggle");
  contentDiv.innerHTML = "";
  outputDiv.classList.remove("markdown-collapsed");
  toggleBtn.textContent = "展开/收起";
  toggleBtn.style.display = "none";
  $("status").textContent = "";
};

resetTweaksToDefault();

$("send").onclick = async () => {
  let tweaks;
  try { tweaks = collectTweaks(); } catch (e) { $("status").textContent = e.message; return; }
  const url = $("url").value.trim();
  const session_id = $("session_id").value.trim() || DEFAULT_SESSION;
  const api_key = $("api_key").value.trim();
  const input_value = $("input_value").value;
  const stream_output = $("stream_output").checked;
  $("status").textContent = "请求中…";
  $("duration").value = "";
  const outputDiv = $("output");
  const contentDiv = outputDiv.querySelector(".markdown-content");
  const toggleBtn = outputDiv.querySelector(".markdown-toggle");
  contentDiv.innerHTML = "";
  outputDiv.classList.remove("markdown-collapsed");
  toggleBtn.textContent = "展开/收起";
  toggleBtn.style.display = "none";
  try {
    if (stream_output) {
      const startTime = Date.now();
      const response = await fetch("/api/invoke-stream", { method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({ url, session_id, input_value, tweaks, api_key }) });
      if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.detail || "请求失败"); }
      const reader = response.body.getReader();
      const decoder = new TextDecoder();
      let buffer = "";
      let fullText = "";
      while (true) {
        const { done, value } = await reader.read();
        if (done) break;
        buffer += decoder.decode(value, { stream: true });
        const lines = buffer.split('\n\n');
        buffer = lines.pop();
        for (const line of lines) {
          if (!line.trim() || !line.startsWith('data: ')) continue;
          try {
            const dataStr = line.slice(6);
            const parsed = JSON.parse(dataStr);
            if (parsed.error) { $("status").textContent = `错误：${parsed.error}`; continue; }
            if (parsed.done) { if (parsed.duration !== undefined) { $("duration").value = parsed.duration.toFixed(2); } $("status").textContent = "完成"; continue; }
            if (parsed.text !== undefined) {
              fullText = parsed.text.trim();
              const markdownHtml = marked.parse(fullText || '');
              contentDiv.innerHTML = markdownHtml;
              Array.from(contentDiv.childNodes).forEach(node => { if (node.nodeType === 3 && node.textContent.trim() === '') { node.parentNode.removeChild(node); } });
              outputDiv.classList.remove("markdown-collapsed");
              toggleBtn.textContent = "收起";
              toggleBtn.style.display = "block";
              toggleBtn.onclick = function() { if (outputDiv.classList.contains("markdown-collapsed")) { outputDiv.classList.remove("markdown-collapsed"); toggleBtn.textContent = "收起"; } else { outputDiv.classList.add("markdown-collapsed"); toggleBtn.textContent = "展开"; } };
            }
          } catch (e) { console.error("解析流数据错误:", e, line); }
        }
      }
      const endTime = Date.now();
      $("duration").value = ((endTime - startTime) / 1000).toFixed(2);
      $("status").textContent = "完成";
    } else {
      const resp = await fetch("/api/invoke", { method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({ url, session_id, input_value, tweaks, api_key }) });
      const data = await resp.json();
      if (!resp.ok) throw new Error(data.detail || "请求失败");
      $("duration").value = data.duration.toFixed(2);
      const contentDiv2 = outputDiv.querySelector(".markdown-content");
      const toggleBtn2 = outputDiv.querySelector(".markdown-toggle");
      if (data.output_text) {
        const markdownHtml = marked.parse(data.output_text || '');
        contentDiv2.innerHTML = markdownHtml;
        outputDiv.classList.remove("markdown-collapsed");
        toggleBtn2.textContent = "收起";
        toggleBtn2.style.display = "block";
        toggleBtn2.onclick = function() { if (outputDiv.classList.contains("markdown-collapsed")) { outputDiv.classList.remove("markdown-collapsed"); toggleBtn2.textContent = "收起"; } else { outputDiv.classList.add("markdown-collapsed"); toggleBtn2.textContent = "展开"; } };
      } else {
        contentDiv2.innerHTML = "";
        outputDiv.classList.remove("markdown-collapsed");
        toggleBtn2.textContent = "展开/收起";
        toggleBtn2.style.display = "none";
      }
      $("status").textContent = "完成";
    }
  } catch (err) { $("status").textContent = "错误：" + err.message; }
};

function replaceAll(s, search, replacement) { return String(s).split(search).join(replacement); }
function escapeForDoubleQuoted(str) { let s = String(str); s = replaceAll(s, "\\", "\\\\"); s = replaceAll(s, '"', '\\"'); return s; }
async function copyText(text) { if (navigator.clipboard && window.isSecureContext) { try { await navigator.clipboard.writeText(text); return true; } catch (e) { return false; } } else { const ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.top = '-1000px'; document.body.appendChild(ta); ta.focus(); ta.select(); let ok = false; try { ok = document.execCommand('copy'); } catch (e) { ok = false; } document.body.removeChild(ta); return ok; } }

$("copy_curl").onclick = () => {
  let tweaks;
  try { tweaks = collectTweaks(); } catch (e) { $("status").textContent = e.message; return; }
  const url = $("url").value.trim();
  const session_id = $("session_id").value.trim() || DEFAULT_SESSION;
  const api_key = $("api_key").value.trim();
  const input_value = $("input_value").value;
  const stream_output = $("stream_output").checked;
  const body = { input_value, output_type: "chat", input_type: "chat", session_id, tweaks };
  let targetUrl = url;
  if (stream_output) targetUrl = url.includes("?") ? url + "&stream=true" : url + "?stream=true";
  const headers = [];
  headers.push("-H \"Content-Type: application/json\"");
  headers.push(`-H \"accept: ${stream_output ? "text/event-stream" : "application/json"}\"`);
  if (api_key) headers.push(`-H \"x-api-key: ${escapeForDoubleQuoted(api_key)}\"`);
  else if (DEFAULT_ENV_API_KEY) headers.push(`-H \"x-api-key: ${escapeForDoubleQuoted(DEFAULT_ENV_API_KEY)}\"`);
  const jsonStr = JSON.stringify(body);
  const escapedJson = escapeForDoubleQuoted(jsonStr);
  const parts = [];
  parts.push("curl");
  if (stream_output) parts.push("-N");
  parts.push("-sS");
  parts.push("-X POST");
  parts.push(`\"${escapeForDoubleQuoted(targetUrl)}\"`);
  parts.push(...headers);
  parts.push(`-d \"${escapedJson}\"`);
  const cmd = parts.join(" ");
  copyText(cmd).then((ok) => { if (ok) { $("status").textContent = "已复制 CURL 命令到剪贴板"; } else { $("status").textContent = "复制失败，请手动复制"; try { window.prompt("CURL 命令", cmd); } catch (_) {} } });
};

function unescapeFromDoubleQuoted(s) { let t = String(s); t = t.split('\\\\').join('\\'); t = t.split('\\"').join('"'); return t; }
function parseCurlHeaders(cmd) {
  const headers = {};
  const re1 = /-H\s+"([^":]+):\s*([^"]*)"/g;
  const re2 = /-H\s+'([^':]+):\s*([^']*)'/g;
  let m;
  while ((m = re1.exec(cmd)) !== null) headers[m[1].toLowerCase()] = m[2];
  while ((m = re2.exec(cmd)) !== null) headers[m[1].toLowerCase()] = m[2];
  return headers;
}
function parseCurlData(cmd) {
  // 匹配 -d 后的引号内容，正确处理转义引号
  let m = cmd.match(/-d\s+"((?:[^"\\]|\\.)*)"/);
  if (!m) m = cmd.match(/-d\s+'((?:[^'\\]|\\.)*)'/);
  if (!m) return null;
  const raw = m[1];
  const jsonStr = unescapeFromDoubleQuoted(raw);
  try { return JSON.parse(jsonStr); } catch (_) { return null; }
}
function parseCurlUrl(cmd) {
  let m = cmd.match(/-X\s+POST\s+"([^"]+)"/);
  if (!m) m = cmd.match(/curl\s+"([^"]+)"/);
  return m ? m[1] : "";
}
$("parse_curl").onclick = () => {
  const cmd = $("curl_input").value;
  if (!cmd || !cmd.trim()) return;
  const url = parseCurlUrl(cmd);
  const headers = parseCurlHeaders(cmd);
  const body = parseCurlData(cmd);
  if (url) $("url").value = url.includes("?stream=true") ? url.replace(/([&?])stream=true(&|$)/, (s, p1, p2) => p1 === '?' ? (p2 ? '?' : '') : '') : url;
  if (headers["x-api-key"]) $("api_key").value = headers["x-api-key"]; 
  const isStream = (headers["accept"] || "").toLowerCase().includes("text/event-stream") || (url || "").includes("stream=true");
  $("stream_output").checked = !!isStream;
  if (body) {
    if (body.session_id) $("session_id").value = body.session_id;
    if (typeof body.input_value === "string") $("input_value").value = body.input_value;
    const containers = document.getElementById('containers');
    containers.innerHTML = '';
    const tweaks = body.tweaks || {};
    const tweakKeys = Object.keys(tweaks);
    
    // 如果 tweaks 为空，不添加任何组件
    if (tweakKeys.length === 0) {
      // 保持容器为空
    } else {
      let emptyComponentBox = null;
      tweakKeys.forEach(key => {
        const value = tweaks[key];
        if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
          const componentName = key;
          const box = createContainer(componentName);
          containers.appendChild(box);
          const keysWrap = box.querySelector('.ct-keys');
          keysWrap.innerHTML = '';
          const kvs = value || {};
          Object.keys(kvs).forEach(tweakKey => {
            const item = kvs[tweakKey];
            let v = item && item.value;
            const t = item && item.type ? item.type : 'str';
            if (t === 'NestedDict' || t === 'Dict' || t === 'list') {
              try {
                v = JSON.stringify(v, null, 2);
              } catch (_) {
                v = String(v);
              }
            } else {
              v = String(v ?? '');
            }
            const el = createKeyItem(tweakKey, t, v);
            keysWrap.appendChild(el);
          });
          if (keysWrap.children.length > 0) box.classList.add('has-keys');
        } else {
          if (!emptyComponentBox) {
            emptyComponentBox = createContainer('');
            containers.appendChild(emptyComponentBox);
            emptyComponentBox.querySelector('.ct-keys').innerHTML = '';
          }
          const keysWrap = emptyComponentBox.querySelector('.ct-keys');
          const tweakKey = key;
          const tweakValue = value;
          let tweakType = 'str';
          if (typeof tweakValue === 'boolean') {
            tweakType = 'bool';
          } else if (typeof tweakValue === 'number') {
            tweakType = Number.isInteger(tweakValue) ? 'int' : 'float';
          }
          const el = createKeyItem(tweakKey, tweakType, String(tweakValue));
          keysWrap.appendChild(el);
          if (keysWrap.children.length > 0) emptyComponentBox.classList.add('has-keys');
        }
      });
    }
  }
  $("status").textContent = "已从 cURL 回填配置";
};
</script>
</body>
</html>